package org.checkerframework.framework.test.diagnostics;

import java.io.File;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.plumelib.util.CollectionsPlume;
import org.plumelib.util.IPair;

/** A set of utilities and factory methods useful for working with TestDiagnostics. */
public class TestDiagnosticUtils {

  /** How the diagnostics appear in Java source files. */
  public static final String DIAGNOSTIC_IN_JAVA_REGEX =
      "\\s*(error|fixable-error|warning|fixable-warning|other):\\s*(\\(?.*\\)?)\\s*";

  /** How the diagnostics appear in Java source files. */
  public static final Pattern DIAGNOSTIC_IN_JAVA_PATTERN =
      Pattern.compile(DIAGNOSTIC_IN_JAVA_REGEX);

  public static final String DIAGNOSTIC_WARNING_IN_JAVA_REGEX = "\\s*warning:\\s*(.*\\s*.*)\\s*";
  public static final Pattern DIAGNOSTIC_WARNING_IN_JAVA_PATTERN =
      Pattern.compile(DIAGNOSTIC_WARNING_IN_JAVA_REGEX);

  // How the diagnostics appear in javax tools diagnostics from the compiler.
  public static final String DIAGNOSTIC_REGEX = ":(\\d+):" + DIAGNOSTIC_IN_JAVA_REGEX;
  public static final Pattern DIAGNOSTIC_PATTERN = Pattern.compile(DIAGNOSTIC_REGEX);

  public static final String DIAGNOSTIC_WARNING_REGEX =
      ":(\\d+):" + DIAGNOSTIC_WARNING_IN_JAVA_REGEX;
  public static final Pattern DIAGNOSTIC_WARNING_PATTERN =
      Pattern.compile(DIAGNOSTIC_WARNING_REGEX);

  // How the diagnostics appear in diagnostic files (.out).
  public static final String DIAGNOSTIC_FILE_REGEX = ".+\\.java" + DIAGNOSTIC_REGEX;
  public static final Pattern DIAGNOSTIC_FILE_PATTERN = Pattern.compile(DIAGNOSTIC_FILE_REGEX);

  public static final String DIAGNOSTIC_FILE_WARNING_REGEX = ".+\\.java" + DIAGNOSTIC_WARNING_REGEX;
  public static final Pattern DIAGNOSTIC_FILE_WARNING_PATTERN =
      Pattern.compile(DIAGNOSTIC_FILE_WARNING_REGEX);

  /**
   * Instantiate the diagnostic based on a string that would appear in diagnostic files (i.e. files
   * that only contain line after line of expected diagnostics).
   *
   * @param stringFromDiagnosticFile a single diagnostic string to parse
   * @return a new TestDiagnostic
   */
  public static TestDiagnostic fromDiagnosticFileString(String stringFromDiagnosticFile) {
    return fromPatternMatching(
        DIAGNOSTIC_FILE_PATTERN,
        DIAGNOSTIC_WARNING_IN_JAVA_PATTERN,
        "",
        null,
        stringFromDiagnosticFile);
  }

  /**
   * Instantiate the diagnostic from a string that would appear in a Java file, e.g.: "error:
   * (message)"
   *
   * @param filename the file containing the diagnostic (and the error)
   * @param lineNumber the line number of the line immediately below the diagnostic comment in the
   *     Java file
   * @param stringFromJavaFile the string containing the diagnostic
   * @return a new TestDiagnostic
   */
  public static TestDiagnostic fromJavaFileComment(
      String filename, long lineNumber, String stringFromJavaFile) {
    return fromPatternMatching(
        DIAGNOSTIC_IN_JAVA_PATTERN,
        DIAGNOSTIC_WARNING_IN_JAVA_PATTERN,
        filename,
        lineNumber,
        stringFromJavaFile);
  }

  /**
   * Instantiate a diagnostic from output produced by the Java compiler. The resulting diagnostic is
   * never fixable and always has parentheses.
   */
  public static TestDiagnostic fromJavaxToolsDiagnostic(
      String diagnosticString, boolean noMsgText) {
    // It would be nice not to parse this from the diagnostic string.
    // However, diagnostic.toString() may contain "[unchecked]" even though getMessage() does
    // not.
    // Since we want to match the error messages reported by javac exactly, we must parse.
    IPair<String, String> trimmed = formatJavaxToolString(diagnosticString, noMsgText);
    return fromPatternMatching(
        DIAGNOSTIC_PATTERN, DIAGNOSTIC_WARNING_PATTERN, trimmed.second, null, trimmed.first);
  }

  /**
   * Instantiate the diagnostic from a JSpecify string that would appear in a Java file, e.g.:
   * "jspecify_some_category".
   *
   * @param filename the file containing the diagnostic (and the error)
   * @param lineNumber the line number of the line immediately below the diagnostic comment in the
   *     Java file
   * @param stringFromJavaFile the string containing the diagnostic
   * @return a new TestDiagnostic
   */
  public static TestDiagnostic fromJSpecifyFileComment(
      String filename, long lineNumber, String stringFromJavaFile) {
    return new TestDiagnostic(
        filename,
        lineNumber,
        DiagnosticKind.JSpecify,
        stringFromJavaFile,
        /* isFixable= */ false,
        /* omitParentheses= */ true);
  }

  /**
   * Instantiate the diagnostic via pattern-matching against patterns.
   *
   * @param diagnosticPattern a pattern that matches any diagnostic
   * @param warningPattern a pattern that matches a warning diagnostic
   * @param filename the file name
   * @param lineNumber the line number
   * @param diagnosticString the string to parse
   * @return a diagnostic parsed from the given string
   */
  @SuppressWarnings("nullness") // TODO: regular expression group access
  protected static TestDiagnostic fromPatternMatching(
      Pattern diagnosticPattern,
      Pattern warningPattern,
      String filename,
      @Nullable Long lineNumber,
      String diagnosticString) {
    final DiagnosticKind kind;
    final String message;
    final boolean isFixable;
    final boolean noParentheses;
    long lineNo = -1;
    int capturingGroupOffset = 1;

    if (lineNumber != null) {
      lineNo = lineNumber;
      capturingGroupOffset = 0;
    }

    Matcher diagnosticMatcher = diagnosticPattern.matcher(diagnosticString);
    if (diagnosticMatcher.matches()) {
      IPair<DiagnosticKind, Boolean> categoryToFixable =
          parseCategoryString(diagnosticMatcher.group(1 + capturingGroupOffset));
      kind = categoryToFixable.first;
      isFixable = categoryToFixable.second;
      String msg = diagnosticMatcher.group(2 + capturingGroupOffset).trim();
      noParentheses = msg.equals("") || msg.charAt(0) != '(' || msg.charAt(msg.length() - 1) != ')';
      message = noParentheses ? msg : msg.substring(1, msg.length() - 1);

      if (lineNumber == null) {
        lineNo = Long.parseLong(diagnosticMatcher.group(1));
      }

    } else {
      Matcher warningMatcher = warningPattern.matcher(diagnosticString);
      if (warningMatcher.matches()) {
        kind = DiagnosticKind.Warning;
        isFixable = false;
        message = warningMatcher.group(1 + capturingGroupOffset);
        noParentheses = true;

        if (lineNumber == null) {
          lineNo = Long.parseLong(diagnosticMatcher.group(1));
        }

      } else if (diagnosticString.startsWith("warning:")) {
        kind = DiagnosticKind.Warning;
        isFixable = false;
        message = diagnosticString.substring("warning:".length()).trim();
        noParentheses = true;
        if (lineNumber != null) {
          lineNo = lineNumber;
        } else {
          lineNo = 0;
        }

      } else {
        kind = DiagnosticKind.Other;
        isFixable = false;
        message = diagnosticString;
        noParentheses = true;

        // this should only happen if we are parsing a Java Diagnostic from the compiler
        // that we did do not handle
        if (lineNumber == null) {
          lineNo = -1;
        }
      }
    }
    return new TestDiagnostic(filename, lineNo, kind, message, isFixable, noParentheses);
  }

  /**
   * Given a javax diagnostic, return a pair of (trimmed, filename), where "trimmed" is the first
   * line of the message, without the leading filename.
   *
   * @param original a javax diagnostic
   * @param noMsgText true if to do work; if false, this returns a pair of (argument, "")
   * @return the diagnostic, split into message and filename
   */
  public static IPair<String, String> formatJavaxToolString(String original, boolean noMsgText) {
    String trimmed = original;
    String filename = "";
    if (noMsgText) {
      if (!retainAllLines(trimmed)) {
        int lineSepPos = trimmed.indexOf(System.lineSeparator());
        if (lineSepPos != -1) {
          trimmed = trimmed.substring(0, lineSepPos);
        }

        int extensionPos = trimmed.indexOf(".java:");
        if (extensionPos != -1) {
          int basenameStart = trimmed.lastIndexOf(File.separator);
          filename = trimmed.substring(basenameStart + 1, extensionPos + 5).trim();
          trimmed = trimmed.substring(extensionPos + 5).trim();
        }
      }
    }

    return IPair.of(trimmed, filename);
  }

  /**
   * Returns true if all lines of the message should be shown, false if only the first line should
   * be shown.
   *
   * @param message a diagnostic message
   * @return true if all lines of the message should be shown
   */
  private static boolean retainAllLines(String message) {
    // Retain all if it is a thrown exception "unexpected Throwable" or it is a Checker
    // Framework Error (contains "Compilation unit") or is OutOfMemoryError.
    return message.contains("unexpected Throwable")
        || message.contains("Compilation unit")
        || message.contains("OutOfMemoryError");
  }

  /**
   * Given a category string that may be prepended with "fixable-", return the category enum that
   * corresponds with the category and whether or not it is a isFixable error
   */
  private static IPair<DiagnosticKind, Boolean> parseCategoryString(String category) {
    String fixable = "fixable-";
    boolean isFixable = category.startsWith(fixable);
    if (isFixable) {
      category = category.substring(fixable.length());
    }
    DiagnosticKind categoryEnum = DiagnosticKind.fromParseString(category);
    if (categoryEnum == null) {
      throw new Error("Unparsable category: " + category);
    }

    return IPair.of(categoryEnum, isFixable);
  }

  /**
   * Returns true if this line in a Java file indicates an expected diagnostic that might be
   * continued on the next line.
   */
  public static boolean isJavaDiagnosticLineStart(String originalLine) {
    String trimmedLine = originalLine.trim();
    return trimmedLine.startsWith("// ::") || trimmedLine.startsWith("// warning:");
  }

  /**
   * Convert an end-of-line diagnostic message to a beginning-of-line one. Returns the argument
   * unchanged if it does not contain an end-of-line diagnostic message.
   *
   * <p>Most diagnostics in Java files start at the beginning of a line. Occasionally, javac issues
   * a warning about implicit code, such as an implicit constructor, on the line <em>immediately
   * after</em> a curly brace. The only place to put the expected diagnostic message is on the line
   * with the curly brace.
   *
   * <p>This implementation replaces "{ // ::" by "// ::", converting the end-of-line diagnostic
   * message to a beginning-of-line one that the rest of the code can handle. It is rather specific
   * (to avoid false positive matches, such as when "// ::" is commented out in source code). It
   * could be extended in the future if such an extension is necessary.
   */
  public static String handleEndOfLineJavaDiagnostic(String originalLine) {
    int curlyIndex = originalLine.indexOf("{ // ::");
    if (curlyIndex == -1) {
      return originalLine;
    } else {
      return originalLine.substring(curlyIndex + 2);
    }
  }

  /** Return true if this line in a Java file continues an expected diagnostic. */
  @EnsuresNonNullIf(result = true, expression = "#1")
  public static boolean isJavaDiagnosticLineContinuation(@Nullable String originalLine) {
    if (originalLine == null) {
      return false;
    }
    String trimmedLine = originalLine.trim();
    // Unlike with errors, there is no logic elsewhere for splitting multiple "warning:"s.  So,
    // avoid concatenating them.  Also, each one must begin a line.  They are allowed to wrap to
    // the next line, though.
    return trimmedLine.startsWith("// ") && !trimmedLine.startsWith("// warning:");
  }

  /**
   * Returns the continuation part. The argument is such that {@link
   * #isJavaDiagnosticLineContinuation} returns true.
   */
  public static String continuationPart(String originalLine) {
    return originalLine.trim().substring(2).trim();
  }

  /**
   * Convert a line in a Java source file to a TestDiagnosticLine.
   *
   * <p>The input {@code line} is possibly the concatenation of multiple source lines, if the
   * diagnostic was split across lines in the source code.
   */
  public static TestDiagnosticLine fromJavaSourceLine(
      String filename, String line, long lineNumber) {
    String trimmedLine = line.trim();
    long errorLine = lineNumber + 1;

    if (trimmedLine.startsWith("// ::")) {
      String restOfLine = trimmedLine.substring(5); // drop the "// ::"
      String[] diagnosticStrs = restOfLine.split("::");
      List<TestDiagnostic> diagnostics =
          CollectionsPlume.mapList(
              (String diagnostic) -> fromJavaFileComment(filename, errorLine, diagnostic),
              diagnosticStrs);
      return new TestDiagnosticLine(
          filename, errorLine, line, Collections.unmodifiableList(diagnostics));

    } else if (trimmedLine.startsWith("// warning:")) {
      // This special diagnostic does not expect a line number nor a file name
      String diagnosticString = trimmedLine.substring(2);
      TestDiagnostic diagnostic = fromJavaFileComment("", 0, diagnosticString);
      return new TestDiagnosticLine("", 0, line, Collections.singletonList(diagnostic));
    } else if (trimmedLine.startsWith("//::")) {
      TestDiagnostic diagnostic =
          new TestDiagnostic(
              filename,
              lineNumber,
              DiagnosticKind.Error,
              "Use \"// ::\", not \"//::\"",
              false,
              true);
      return new TestDiagnosticLine(
          filename, lineNumber, line, Collections.singletonList(diagnostic));
    } else if (trimmedLine.startsWith("// jspecify_")) {
      TestDiagnostic diagnostic =
          fromJSpecifyFileComment(filename, errorLine, trimmedLine.substring(3));
      return new TestDiagnosticLine(
          filename, errorLine, line, Collections.singletonList(diagnostic));
    } else {
      // It's a bit gross to create empty diagnostics (returning null might be more
      // efficient), but they will be filtered out later.
      return new TestDiagnosticLine(filename, errorLine, line, Collections.emptyList());
    }
  }

  /** Convert a line in a DiagnosticFile to a TestDiagnosticLine. */
  public static TestDiagnosticLine fromDiagnosticFileLine(String diagnosticLine) {
    String trimmedLine = diagnosticLine.trim();
    if (trimmedLine.startsWith("#") || trimmedLine.isEmpty()) {
      return new TestDiagnosticLine("", -1, diagnosticLine, Collections.emptyList());
    }

    TestDiagnostic diagnostic = fromDiagnosticFileString(diagnosticLine);
    return new TestDiagnosticLine(
        "", diagnostic.getLineNumber(), diagnosticLine, Arrays.asList(diagnostic));
  }

  public static Set<TestDiagnostic> fromJavaxDiagnosticList(
      List<Diagnostic<? extends JavaFileObject>> javaxDiagnostics, boolean noMsgText) {
    Set<TestDiagnostic> diagnostics = new LinkedHashSet<>(javaxDiagnostics.size());

    for (Diagnostic<? extends JavaFileObject> diagnostic : javaxDiagnostics) {
      // See TestDiagnosticUtils as to why we use diagnostic.toString rather
      // than convert from the diagnostic itself
      String diagnosticString = diagnostic.toString();

      // suppress Xlint warnings
      if (diagnosticString.contains("uses unchecked or unsafe operations.")
          || diagnosticString.contains("Recompile with -Xlint:unchecked for details.")
          || diagnosticString.endsWith(" declares unsafe vararg methods.")
          || diagnosticString.contains("Recompile with -Xlint:varargs for details.")) {
        continue;
      }

      diagnostics.add(TestDiagnosticUtils.fromJavaxToolsDiagnostic(diagnosticString, noMsgText));
    }

    return diagnostics;
  }

  /**
   * Converts the given diagnostics to strings (as they would appear in a source file individually).
   *
   * @param diagnostics a list of diagnostics
   * @return a list of the diagnastics as they would appear in a source file
   */
  public static List<String> diagnosticsToString(List<TestDiagnostic> diagnostics) {
    return CollectionsPlume.mapList(TestDiagnostic::toString, diagnostics);
  }

  public static void removeDiagnosticsOfKind(
      DiagnosticKind kind, List<TestDiagnostic> expectedDiagnostics) {
    for (int i = 0; i < expectedDiagnostics.size(); /*no-increment*/ ) {
      if (expectedDiagnostics.get(i).getKind() == kind) {
        expectedDiagnostics.remove(i);
      } else {
        ++i;
      }
    }
  }
}
